전체 포스트

구조적 타이핑과 객체 타입 이해하기

타입스크립트의 구조적 타이핑에 대해 살펴봅니다
2/23/2023 작성

개요

안녕하세요 이정환입니다 😃

앞서 타입간의 계층 관계와 호환성에 대해 살펴보았습니다.

또 any, void, unknown, never등의 헷갈리는 독특한 타입들에 대해서도 함께 살펴보았습니다. 이번에는 객체 타입의 호환성에 대해 살펴보겠습니다.

혹시 앞선 내용 (타입과 집합, 호환성, 트리)에 대해 아직 잘 모른다면 먼저 읽고 오시는 걸 추천드립니다.

타입과 집합, 호환성, 트리 - Winterlood
타입과 집합, 호환성, 트리 - 타입과 집합, 타입간의 호환성 그리고 타입 계층도를 이해해 봅시다
타입과 집합, 호환성, 트리 - Winterlood링크의 썸네일 이미지

구조적 타이핑(Structural Typing)

앞서 타입스크립트의 타입이란 “동일한 속성과 메서드를 갖는 값의 집합”이라고 살펴본 적 있습니다. 다시 말해 타입스크립트에서는 타입을 그 타입이 갖는 속성과 값의 집합으로 정의합니다.

따라서 타입스크립트에서는 타입이 어떤 이름을 갖고 있는지가 중요하지 않습니다.이 타입에 속하는 값들이 갖는 메서드, 프로퍼티, 연산 등의 기능적인 요소를 중심으로 구조적으로 타입이 정의됩니다. 타입스크립트의 이런 특징을 ‘구조적 타이핑’이라고 합니다. 구조적으로 타입을 결정한다는 의미로 해석할 수 있습니다.

참고로 타입스크립트에서는 만약 두 타입의 구조(메서드, 프로퍼티, 연산)이 모두 같다면 이 두 타입이 호환됩니다.

서론부터 너무 어려웠나요? 구조적 타이핑은 객체 타입을 통해 더 쉽게 이해할 수 있습니다. 아주 간단한 예제 들을 천천히 살펴보며 더 쉽게 이해 해 보겠습니다.

객체의 타입 선언과 구조적 타이핑

앞선 섹션에서 객체의 타입을 선언할 때에는 보통 object 타입이 아닌 객체 리터럴 타입으로 선언 한다고 배웠습니다. 객체를 의미하는 ‘object’ 타입이 존재함에도 객체 타입을 객체 리터럴 타입으로 선언하는 이유는 다음과 같은 문제가 존재하기 때문이었습니다.

COPY
let person: object = {
  name: "이정환",
  age: 27,
};

person.name;
// 오류 : object 타입에는 name 프로퍼티가 없음

객체의 타입을 object로 선언하면 이 타입이 ‘객체’라는 것 외에는 어떤 프로퍼티나 메서드의 정보도 알 수 없기 때문에 프로퍼티에 접근하지 못하는 문제가 있었습니다.

따라서 객체의 타입을 선언할 때에는 보통 다음과 같이 객체 리터럴 타입을 선언합니다.

COPY
type Person = {
  name: string;
  age: number;
};

let person: Person = {
  name: "이정환",
  age: 27,
};

타입 별칭(Alias)를 이용해 선언한 Person 타입을 person 변수의 타입으로 선언했습니다. 이렇듯 객체의 타입을 선언할 때에는 단순히 객체임을 의미하는 object 타입으로 선언하는게 아닌 그 객체의 구조적인 특징을 명시하는 객체 리터럴 타입을 사용합니다. 이것이 바로 타입을 선언할 때 구조(값이 갖는 프로퍼티, 메서드, 연산)을 중심으로 선언하는 구조적 타이핑입니다.

구조적 타이핑의 특징

타입을 이름이 아닌 구조를 기반으로 정의하는 ‘구조적 타이핑’에 대해 살펴보았습니다. 구조적 타이핑은 한가지 독특한 특징을 갖는데 그것은 바로 구조(프로퍼티, 메서드, 연산)가 같으면 서로 다른 타입이라도 호환된다 라는 점 입니다.

여러분을 위해 한가지 문제를 준비했습니다. 아래의 코드에 타입 오류가 있을까요?

COPY
type Animal = {
  name: string;
  color: string;
};

type Dog = {
  name: string;
  color: string;
  breed: string;
};

let dog: Dog = {
  name: "바둑이",
  color: "white",
  breed: "진돗개",
};

let animal: Animal = dog; // 타입 오류가 발생할까요 ???

마지막 라인에서 타입 오류가 발생할 것이라고 예상 하셨다면 아쉽게도 틀렸습니다.

놀랍게도 이 코드는 타입 오류가 발생하지 않는 없는 정상적인 코드입니다. 뭔가 이상하지 않나요? Animal 타입 변수에 Dog 타입 값을 할당하는데 아무런 오류도 발생하지 않습니다. 타입스크립트에 버그가 있는 걸까요? 도대체 왜 이런 일이 발생하는 걸까요?

객체 타입 간의 슈퍼-서브 타입 관계

이것을 이해하려면 우선 객체 리터럴 타입이 어떤 값들을 포함 하는지 알아야 합니다.

COPY
type Animal = {
  name: string;
  color: string;
};

위와 같이 선언한 Animal 타입에는 다음과 같은 값들이 포함됩니다.

“name(string), color(string) 프로퍼티를 포함하고 있는 모든 객체”

COPY
type Animal = {
  name: string;
  color: string;
};

let other = {
  name: "이름",
  color: "색깔",
  arr: [],
  obj: {
    a: 1,
  },
};

let animal: Animal = other;
// 문제 없음

변수 other에 저장한 객체에는 name, color외에도 arr, obj 등의 추가적인 프로퍼티들이 있습니다. 그럼에도 name, string 프로퍼티를 갖기 때문에 Animal 타입의 값으로 생각할 수 있습니다.

💡 객체 리터럴을 타입이 선언된 변수에 직접 할당할 때에는 오류가 발생합니다.
한가지 주의할 점은 위 코드처럼 초과 프로퍼티를 보유한 객체 리터럴을 Animal 타입의 변수에 직접 할당하려고 하면 오류가 발생합니다.

이를 초과 프로퍼티 검사라고 하는데 나중에 자세히 다룹니다

COPY
type Animal = {
  name: string;
  color: string;
};

let animal: Animal = {
  name: "이름",
  color: "색깔",
  arr: [], // 오류
};

한가지 주의할 점은 위 코드처럼 초과 프로퍼티를 보유한 객체 리터럴을 Animal 타입의 변수에 직접 할당하려고 하면 오류가 발생합니다.

이를 초과 프로퍼티 검사라고 하는데 나중에 자세히 다룹니다

COPY
type Animal = {
  name: string;
  color: string;
};

let animal: Animal = {
  name: "이름",
  color: "색깔",
  arr: [], // 오류
};

이제 다시 Animal과 Dog 타입간의 관계를 살펴보겠습니다.

COPY
type Animal = {
  // Dog의 슈퍼타입
  name: string;
  color: string;
};

type Dog = {
  // Animal의 서브 타입
  name: string;
  color: string;
  breed: string; // 추가적인 프로퍼티
};

Dog 타입은 Animal 타입보다 많은 프로퍼티를 갖고 있습니다. 그리고 Animal 타입에 선언된 모든 프로퍼티를 갖고 있습니다. 그러므로 모든 Dog 타입 값은 Animal 타입에 포함됩니다. 결론적으로 Dog 타입은 Animal 타입의 서브타입입니다.

정리하자면 타입스크립트에서는 동일한 프로퍼티를 갖는 두 객체 타입이 있을 때 추가적인 프로퍼티를 갖는 타입이 그렇지 않은 타입의 서브 타입이 됩니다.

Dog 타입에 해당하는 값들은 Animal 타입에 해당하는 값들이 갖는 프로퍼티를 모두 갖습니다. 따라서 Dog 타입의 모든 값을 Animal 타입의 값이라고 해도 무방합니다. 마치 “모든 개는 동물이다”라고 표현하는 것과 같으며 이는 말이 됩니다.

반대로 Animal 타입에 해당하는 값들은 Dog 타입에 해당하는 값들이 갖는 프로퍼티를 모두 갖지 않습니다. Dog 타입에는 Animal 타입에는 없는 breed라는 추가적인 프로퍼티가 존재하기 때문입니다. 따라서 모든 Animal 타입의 값을 Dog 타입 값으로 보기에는 어렵습니다. 이것은 마치 “모든 동물은 개다”라고 표현하는 것과 같으며 모순입니다.

이때 Dog 타입의 breed와 같은 추가적인 프로퍼티를 ‘초과 프로퍼티’ 또는 ‘잉여 프로퍼티’라고 부릅니다. 단 다음과 같이 서로 공유하지 않는 프로퍼티를 갖고 있는 두 객체 타입의 경우 당연히 이런 관계가 성립하지 않습니다.

COPY
type Person = {
  name: string;
  loc: string;
};

type Dog = {
  name: string;
  breed: string;
};

let dog: Dog = {
  name: "바둑이",
  breed: "진돗개",
};

let person: Person = dog;

Dog 타입과 Person 타입은 name이라는 동일한 프로퍼티를 갖지만 각각 loc, breed 같은 공유하지 않는 프로퍼티를 갖습니다. 이럴 경우 두 타입중 어느 타입도 서로의 슈퍼 또는 서브 타입이 되지 않습니다.

객체 리터럴 타입간의 교집합 타입

객체 리터럴 타입은 명시된 프로퍼티를 갖는 모든 객체를 포함하는 타입임을 알았습니다. 앞서 살펴본 예제의 Person과 Dog 타입간의 관계를 집합으로 표현하면 아래와 같습니다

서로 부모-자식 관계를 갖지 않는다는 것은 알겠는데 왜 교집합이 존재하는 걸까요? 두 타입 모두에 해당하는 값이 존재하기 때문입니다. 예를 들면 다음과 같습니다.

COPY
(...)
let dogPerson = {
  name: "개사람",
  loc: "서울",
  breed: "인간",
};

let dog: Dog = dogPerson;
let person: Person = dogPerson;

변수 dogPerson은 name, loc, breed 프로퍼티를 갖습니다. dogPerson은 Dog 타입의 값으로 볼 수 있습니다. name, breed 프로퍼티를 갖기 때문입니다. 동시에 Person 타입의 값으로도 볼 수 있습니다. name, loc 프로퍼티를 갖기 때문입니다. 결국 dogPerson은 Dog, Person 타입에 모두 속합니다. 두 타입의 교집합으로 볼 수 있습니다.

만약 교집합 타입을 별도로 선언하려면 다음과 같이 대수타입의 인터섹션 타입을 이용하면 됩니다.

COPY
(...)
type DogPerson = Person & Dog;
// 만들어진 교집합 타입
// {
//   name: string;
//   breed: string;
//   loc: string;
// }

let dogPerson: DogPerson = {
  name: "개사람",
  loc: "서울",
  breed: "인간",
};

let dog: Dog = dogPerson;
let person: Person = dogPerson;

DogPerson 타입은 Dog 타입의 서브타입이면서 동시의 Person 타입의 서브타입입니다.

 

정리하자면 타입스크립트는 타입은 값의 구조(프로퍼티, 메서드, 연산)에 따라 정의됩니다. 예를 들어 toFixed 메서드를 가지며 사칙연산이 가능한 값들을 묶어 ‘number’ 라고 정의하는 것 과 같습니다. 이런 특징을 구조적 타이핑 이라고 합니다. 그러므로 객체 리터럴 타입은 자신과 구조가 같다고 볼 수 있는 모든 값을 포함합니다. 쉽게 말해 객체 리터럴 타입을 선언할 때 명시된 프로퍼티를 모두 가지고 있는 모든 객체를 포함합니다.

초과 프로퍼티 검사

아래의 코드는 오류가 발생할까요?

COPY
type Person = {
  name: string;
};

let person: Person = {
  name: "이정환",
  age: 27,
};

person 변수에 할당 하려는 객체는 구조적으로 분명 Person 타입의 값으로 볼 수 있습니다. age라는 초과 프로퍼티가 있지만 name 프로퍼티를 갖고 있기 때문입니다. 그런데 타입스크립트는 이렇게 선언된 변수에 객체 리터럴 값을 직접 할당할 때에는 매우 예외적으로 초과 프로퍼티를 허용하지 않습니다. 이를 ‘초과 프로퍼티 검사(excess property checking)’라고 합니다.

따라서 위 코드는 오류가 발생합니다.

COPY
(...)
let person: Person = {
  name: "이정환",
  age: 27, // 오류 (아래에 자세히)
};

// 타입 오류
// '{ name: string; age: number; }' 형식은 'Person' 형식에 할당할 수 없습니다.
// 개체 리터럴은 알려진 속성만 지정할 수 있으며 'Person' 형식에 'age'이(가) 없습니다.

초과 프로퍼티 검사는 타입과 완벽히 동일한 구조를 갖는 객체만 허용합니다. 따라서 person 변수에는 Person 타입을 선언할 때 명시한 name 프로퍼티만 갖는 객체만 할당할 수 있습니다.

그렇다면 언제 이런 초과 프로퍼티 검사가 일어나는 걸까요? 타입스크립트에서는 다음과 같은 상황에 초과 프로퍼티를 검사합니다.

  1. 객체 리터럴을 타입이 선언된 변수에 할당할 때
  2. 객체 리터럴을 함수의 인수로 전달할 때

두가지 상황을 예제와 함께 살펴보겠습니다.

객체 리터럴을 타입이 선언된 변수에 할당할 때

초과 프로퍼티 검사가 발생하는 첫번째 상황은 “객체 리터럴을 타입이 선언된 변수에 직접 할당할 때”입니다. 앞서 살펴본 예제가 바로 이 상황입니다.

COPY
(...)
let person: Person = {
  name: "이정환",
  age: 27, // 초과 프로퍼티 검사로 인한 오류
};

초과 프로퍼티 검사를 무시하고 값을 할당하려면 다음과 같이 코드를 수정하면 됩니다.

COPY
type Person = {
  name: string;
};

let person: Person; // 초기화는 하지 않음

let tmp = {
  name: "이정환",
  age: 27,
};
person = tmp;

객체 리터럴을 person에 바로 할당하는게 아닌, tmp 라는 변수에 먼저 할당합니다. 그 다음 tmp의 값을 person에 할당합니다. 이렇게 하면 객체 리터럴을 타입이 선언된 변수(person)에 직접 할당하지 않기 때문에 초과 프로퍼티 검사를 받지 않습니다.

만약 초기화가 꼭 필요한 상황이라면 이렇게 해도 됩니다.

COPY
type Person = {
  name: string;
};

let tmp = {
  name: "이정환",
  age: 27,
};
let person: Person = tmp;

이렇듯 타입이 선언된 변수에 객체 리터럴을 직접 할당하면 초과 프로퍼티 검사가 발생합니다. 따라서 검사가 필요하지 않은 상황이라면 위 두가지 방법중 한 가지를 택해야 합니다.

객체 리터럴을 함수의 인수로 전달할 때

초과 프로퍼티 검사가 발생하는 두 번째 상황은 “객체 리터럴을 함수의 인수로 전달할 떄”입니다.

다음과 같이 함수를 호출 할 때 인수로 객체 리터럴을 직접 전달하면 초과 프로퍼티 검사를 받습니다.

COPY
type Person = {
  name: string;
};

function func(person: Person) {}

func({
  name: "이정환",
  age: 27, // 오류
});

초과 프로퍼티 검사를 받고싶지 않다면 다음과 같이 변수에 먼저 객체 값을 할당한 다음 이 변수를 인수로 전달하면 됩니다.

COPY
type Person = {
  name: string;
};

function func(person: Person) {}

let person = {
  name: "이정환",
  age: 27,
};

func(person);

이렇게 초과 프로퍼티 검사가 언제 발생하는지 살펴보며 각 상황에 어떻게 회피할 수 있는지도 함께 살펴보았습니다. 초과 프로퍼티 검사는 객체 타입의 프로퍼티를 아주 엄밀하게 제한해야 할 경우에 요긴하게 사용할 수 있습니다.